昨天我們把 host 的 ssh-agent socket 轉發進容器,交出去的是「幫我簽這段 bytes」的能力,私鑰從頭到尾沒有離開 host。
今天要處理那句話沒講完的部分。先把結局放在這裡:規則套用成功了。錯的規則,成功地套用了。
本日分享的東西都在 public repo 的
dev-container底下,主角是那支init-firewall.sh。
「不要把安全規則寫在 prompt 裡,要寫在模型外面的程式碼。模型會誤解、會忘記、會被 injection 影響,所以要讓它就算判斷錯誤也無法越界。」
這句話是對的,我也是這樣做的。今天要立的這道牆是 iptables,root 才改得動;容器裡的 agent 以非 root 的身分執行;腳本是 root 所有,它沒有寫入權。規則在模型外面,而且在它碰不到的地方。
然後 agent 還是自己把白名單擴大了 🫨!!!
不是它繞過了防火牆規則,是我寫規則的時候少打了兩個引號。更難看的是:它擴大完之後,我的自我驗證腳本照樣在畫面上印出「防火牆已驗證。」
「把規則寫到模型外面」只解決了一半的問題。另一半是:那份寫在外面的規則,誰來驗?而驗它的東西,會不會跟它一起錯?
今天這篇的後半段就是這件事的現場。
ssh-agent 能做的三件事裡,第二件是「幫我簽這段 bytes」。在一般、沒有加上 destination constraint 的用法裡,sign request 本身不會另外帶一個「我要連哪台主機」的欄位。
所以在沒有其他限制時,容器拿到那個 socket 之後,就能對任何一台連得到、而且認得那把公鑰的主機完成認證。不是只有 GitLab。
如果每台伺服器都用不同的金鑰,這件事還不算太嚴重。但我的金鑰是複用的,Day 19 開頭那段講過。
我當時不知道 OpenSSH 8.9 早在 2022 年 2 月 23 日就加入了一套限制 ssh-agent 金鑰轉送與使用範圍的機制;官方的詳細說明把它稱為 destination constraints。換句話說,不是 SSH 這一層做不到,而是我當時漏掉了既有能力,才把限制往網路層下推。若環境支援,可以在載入金鑰時透過 ssh-add -h 限定可用目的地,替 agent forwarding 再加一道限制。
我當時選的是把限制往網路層下推;今天要做的,就是把這道限制真的立起來。
Anthropic 官方的 dev-container 裡本來就有一支 init-firewall.sh:
我這份是從它改的。有一整段我原樣沿用,先講那一段,因為它處理的是一個不容易發覺的坑。
容器裡看一下 /etc/resolv.conf:
nameserver 127.0.0.11
127.0.0.11 是 Docker 自訂網路使用的 embedded DNS server 位址。不過,實際實作不是讓 resolver 直接監聽這個位址的 53 埠,而是由 NAT 規則把封包轉送到 resolver 實際綁定的高位埠。
所以你寫防火牆的第一個動作如果是「先清乾淨」:
iptables -t nat -F
DNS 就一起被清掉了。
我在一顆最小的容器裡實際跑了一次:
flush 前:nat 表裡 127.0.0.11 的規則有 6 條
解析 OK
只做 iptables -t nat -F,不還原:
❌ 解析失敗 — 網域名稱解不出來了
六條規則,清掉就是全死。
麻煩的地方在症狀。這時候你看到的錯誤是「解析不到 xxx.com」,長得像網路壞掉、像 DNS 設定有問題,完全不像「你的防火牆生效了」。你會去查 DNS,不會去查 iptables。
官方腳本的處理是:flush 之前先把那幾條規則撈出來,flush 之後只還原它們。
# 1. flush 之前,先把 Docker 內部 DNS 的 NAT 規則撈出來
DOCKER_DNS_RULES=$(iptables-save -t nat | grep "127\.0\.0\.11" || true)
iptables -t nat -F
# ...其餘 flush
# 3. 只還原 Docker DNS,其餘不還原
if [ -n "$DOCKER_DNS_RULES" ]; then
iptables -t nat -N DOCKER_OUTPUT 2>/dev/null || true
iptables -t nat -N DOCKER_POSTROUTING 2>/dev/null || true
echo "$DOCKER_DNS_RULES" | xargs -L 1 iptables -t nat
fi
這裡還有一個時間差:
接 Docker 預設 bridge 的時候,/etc/resolv.conf 用的是 host 的 DNS,根本沒有那六條規則。 只有當你把容器接上自己建的 network(Day 18 那個給 gitlab-proxy 用的 named bridge 就是),才會走 127.0.0.11。
也就是說,你今天使用這份腳本、覺得這段看不懂而且好像沒作用、順手刪掉,當下不會有任何症狀。它會等到某一天你把容器接上一張自己的網路,才第一次爆給你看。
白名單、SSH、驗證方式我都調整過。
| 官方版 | 我的版本 | |
|---|---|---|
| 白名單 | GitHub 全 IP 段 + registry.npmjs.org + sentry + statsig + VSCode marketplace + api.anthropic.com |
只有 api.anthropic.com |
| SSH 22 | 放行到任何主機 | 只放行 dig 解出來的那台 GitLab |
| 直連網段 | 從 default route 回推一個 /24 |
讀 ip route 列出實際的直連網段 |
| 驗證 | example.com 不通 + api.github.com 通 |
example.com 不通 + 每一個白名單網域都要通 |
| 開關 | 沒有,一律套用 | 啟動時由人選 |
⚠ 表裡的「只有 api.anthropic.com」,指的是用來建立 ipset 的 hostname allowlist。腳本會在啟動時解析它,再把當下取得的 IPv4 位址放進 ipset;真正執法時比對的是 IP,不是 hostname 或 TLS SNI,而且沒有再限制協定或連接埠。同一個 IP 若同時承載其他服務,那些服務也可能跟著變得可達。它不等於「容器什麼都連不出去」:DNS 是放行的,而查詢名稱本身就是一條出境通道。這件事寫在文末,先在這裡標一下,免得中間這幾段被讀成絕對的。
另外,腳本會對所有 directly connected Docker subnet 放行全協定、全連接埠,不只 gitlab-proxy。這是目前為容器間服務接受的 tradeoff,不是 service-level allowlist。
這份腳本目前只設定 IPv4 的 iptables,沒有另外設定 ip6tables。現行容器實測沒有 global IPv6 位址,也沒有 IPv6 default route,因此這個前提目前成立;但它是環境前提,不是永久保證。日後若替 Docker network 啟用 IPv6,就必須同步補上 IPv6 規則,否則這道牆只會罩住 IPv4。啟動時的負向連線測試可能抓到這個缺口,但不能當成保證;啟用 IPv6 時,仍要明確測 IPv6 並補上規則。
兩份腳本要服務的情境不同。官方那份是「讓 dev-container 還能開發」,你在裡面要 clone、要裝套件、要裝 VSCode extension,所以 GitHub、npm、marketplace 都得開。
我的目標是「把容器的直接出網收斂到審查所需」。審查要做的事情很有限:跟模型講話、透過代理讀 GitLab、把報告寫回去。所以我從一張白紙開始加,加到能跑就停。
這條就是文章開頭那件事的解法。
官方那份是不分對象放行 --dport 22,也就是連往任何主機的 SSH 都通。以它的情境(開發用容器,你自己在裡面操作)沒問題。但我的容器裡有一個轉發進來的 agent socket,而且我的金鑰是複用的。這兩件事加起來,22 全開等於「容器被打下來,我所有機器都跟著」。
改成 boot 時解析一次 GitLab 的 IP,只放行到那幾個位址:
gitlab_ips=$(dig +short +tries=2 +time=3 A "$GITLAB_SSH_HOST" | grep -E '^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$' || true)
while read -r gip; do
iptables -A OUTPUT -p tcp -d "$gip" --dport 22 -j ACCEPT
iptables -A INPUT -p tcp -s "$gip" --sport 22 -m state --state ESTABLISHED -j ACCEPT
done <<< "$gitlab_ips"
$GITLAB_SSH_HOST 在我這裡是公司內部的 GitLab。你如果沒有這個需求,這一整段可以直接拿掉(那就是一條 SSH outbound 都不開);如果你的 git 也在內部某台機器上,就換成你家那台的主機名。
已知限制寫在註解裡:這是開機當下的快照。IP 換了就得重開容器。內部 GitLab 的 IP 很穩定,我接受這個代價;反過來說,如果做成動態跟隨 DNS,等於把白名單的控制權交給 DNS 回應,那不划算。
同一個限制也適用於白名單那邊。api.anthropic.com 走 CDN、TTL 很短,長時間的 session 中途換 IP 的話請求會被擋掉,只能重開容器。
規則排完之後,最後三步:
# 7. 預設 DROP
iptables -P INPUT DROP
iptables -P FORWARD DROP
iptables -P OUTPUT DROP
# 已經建立的連線放行,白名單放行
iptables -A INPUT -m state --state ESTABLISHED,RELATED -j ACCEPT
iptables -A OUTPUT -m state --state ESTABLISHED,RELATED -j ACCEPT
iptables -A OUTPUT -m set --match-set allowed-domains dst -j ACCEPT
# 8. 其餘 REJECT
iptables -A OUTPUT -j REJECT --reject-with icmp-admin-prohibited
三個名詞先解釋一下。
ipset 是一個「IP 位址的集合」,可以整包丟給一條 iptables 規則去比對。不用它的話,每個白名單 IP 都要一條規則。
ESTABLISHED,RELATED 是連線狀態。iptables 記得每一條連線目前走到哪一步:
ESTABLISHED — 這個封包屬於一條已經建立起來的連線RELATED — 這個封包屬於一條由既有連線衍生出來的新連線,典型的例子是回傳的 ICMP 錯誤訊息這兩條為什麼非有不可:INPUT 的預設政策也是 DROP,所以我送出去的請求就算被放行了,對方回來的封包會在 INPUT 被丟掉。結果是每一個連線都建立不起來,包括白名單裡的那些。有了這條,回程封包因為屬於一條既有連線而被放行,我就不必為了收回應而開一堆 INPUT 規則。
REJECT 而不是 DROP,這是刻意的:
DROP 是把封包丟掉、什麼都不回。對方會一路等下去。REJECT 是明確回一個「不准」。對方立刻收到錯誤。我用同一個容器、同一個目標位址各量了一次:
REJECT 等了 0 秒 (更精確一點:curl 回報 2 ms)
DROP 等了 133 秒
這次我沒有另外設定較短的 curl 連線逾時;curl/libcurl 的 connect timeout 預設是 300 秒,但這次 Linux 的 TCP 連線嘗試先在約 133 秒結束。這是當次環境的量測結果,不是跨環境常數。
那兩分鐘裡 agent 不會做任何事,你也不知道它在等什麼。我要的是撞牆的當下就知道撞到牆。
腳本的最後一步是自我驗證:
# example.com 必須連不到
if curl --connect-timeout 5 -sS -o /dev/null https://example.com 2>/dev/null; then
echo "錯誤:example.com 連得到 — 防火牆沒有生效"
exit 1
fi
# 每一個白名單網域都必須連得到
for domain in "${ALLOWED_DOMAINS[@]}"; do
if ! curl --connect-timeout 5 -sS -o /dev/null "https://${domain}/" 2>/dev/null; then
echo "錯誤:${domain} 連不到 — 白名單沒生效"
exit 1
fi
done
兩個方向都要測。 這個做法我是從官方那份學來的。
只測「該擋的有沒有擋住」,會漏掉「我不小心把全部都擋掉了」。那種情況下 agent 從第一次呼叫模型就開始失敗,而它看到的錯誤訊息不會提到 iptables 一個字。你會以為是 API 掛了、以為是憑證過期,查半天才想到牆。
驗證失敗就 exit 1,而 entrypoint 收到失敗會直接結束、不啟動 CLI。這裡的預設值也很重要:牆沒起來就不要放 agent 進來。「規則套用失敗,所以先開著跑吧」是這類腳本最常見的錯誤結尾。
這道牆是 root 才能改的,而容器裡的 agent 是以非 root 的 nathan 執行。腳本本身也是 root 所有、nathan 沒有寫入權。這點是前提,不然「只白名單這支腳本」根本沒有意義,能改腳本就等於能改規則。看起來沒問題。
但 entrypoint 要套規則,所以 nathan 得能 sudo 跑那支腳本。我原本是這樣寫的:
nathan ALL=(root) NOPASSWD: /usr/local/bin/init-firewall.sh
寫這行的時候我的想法是「只白名單這一支腳本,很安全」。
sudoers 的語義是:命令後面沒有列參數,代表任何參數都允許。
而我的腳本會把位置參數當成「要額外放行的網域」加進白名單。兩件事乘起來,容器裡的 agent 只要跑:
sudo /usr/local/bin/init-firewall.sh attacker.example.com
整道牆就會被重建,而且多一個它自己指定的網域。
而且腳本會繼續往下跑到自我驗證,驗證還是會通過:example.com 確實還是不通、白名單網域確實都通得到。所以畫面上印出來的是:
防火牆已驗證。
看起來一切正常。
我把兩邊都改掉。腳本改成完全不吃位置參數,白名單直接寫死在檔案開頭,要加就改檔案再 rebuild;sudoers 這端也把參數鎖成空,"" 表示只准無參數呼叫:
nathan ALL=(root) NOPASSWD: /usr/local/bin/init-firewall.sh ""
修完再測一次:
$ sudo /usr/local/bin/init-firewall.sh example.com
sudo: a password is required
順便學到一件事:sudoers 寫壞不會讓 build 失敗,會等到你真的 sudo 的那一刻才變成 a password is required。那時候人已經在容器裡了。所以我在 Dockerfile 加了一行 visudo -c 在 build 時驗語法。
同一個問題還有第二個版本。
GitLab 的主機名總得從外面傳進去,最直覺會想到環境變數。但如果這個值最後能由容器裡的 nathan 控制,它就不適合成為安全政策的來源。
所以我沒有採用這條路,而是在 build 時把主機名寫進一個 root 所有、0444 的檔案,腳本再從那裡讀取:
RUN mkdir -p /etc/ncr && \
printf '%s' "$GITLAB_SSH_HOST" > /etc/ncr/gitlab-ssh-host && \
chmod 0444 /etc/ncr/gitlab-ssh-host
agent 改不動也蓋不掉(想用 bind mount 蓋過去需要 CAP_SYS_ADMIN,而容器只有 NET_ADMIN)。
說白一點就是:不要讓被關的人自己挑監獄。
參數是呼叫端控制的輸入面,環境變數是執行身分控制的輸入面。兩個都不能當政策來源。
容器啟動時的第一個問題:
網路能力:
1 = 限制(白名單) — 一般 TCP 出站收斂到 api.anthropic.com、直連的 docker 網段
(gitlab-proxy),SSH 22 只通 build 時指定的那台 GitLab(預設)
⚠ DNS 不在這道牆裡:查詢名稱仍出得去,這不是 DLP
2 = 完全開放 — 不套用任何 iptables 規則
這三行是啟動選單的用途摘要,不是 ruleset 契約。實際規則仍包含前面提到的 IP-level allowlist,以及 directly connected Docker subnet 的全協定、全連接埠放行;畫面上的「一般 TCP」不能拿來推論完整邊界。DLP 是 Data Loss Prevention(資料外洩防護)的縮寫。
我知道留一個「完全開放」看起來像自己開後門。但我不是只在醫院的情境用這個容器。要做技術研究、要讓瀏覽器自動化真的連得出去的時候,限制模式會擋住它們,而那些場合本來就不該在限制模式下硬幹。
我在意的是三件事,只要這三件成立,這個開關就不是後門:
還有一個要區分清楚的東西。Claude Code 的預設值也正在改變。2026 年 7 月 3 日,v2.1.200 才把原本的預設權限模式改名為 Manual;到了 8 月 14 日,Pro、Max 與 Team 方案的新 session 已改由 Auto mode 啟動,除非使用者或管理員另外固定了預設值。Auto mode 不再要求人逐項按下核准,而是把每次工具呼叫交給背景 classifier 判斷。有人可能會覺得:那不就取代這道牆了嗎?
不是同一層的東西。
權限模式決定的是「這個動作由誰判斷能不能執行」,防火牆決定的是「封包實際能不能出去」。 一個在 Auto mode 下被 classifier 放行的 curl,跟那個 curl 打不打得出去,是兩件事。前者是執行授權,後者是網路可達性。就算之後 Anthropic 把分類器做得再聰明,這道牆的理由都不會過期。
規則寫完、驗證通過、開關也做好了。然後我遇到一件事。
有一次 Claude 講出了一個我們只寫在 Google Doc 上的資訊。
當下第一個念頭是「我漏了什麼設定」,第二個念頭是「原來這條路一直在」。兩個念頭幾乎是同時的。
我沒有去翻文件,我直接問它:你怎麼知道這件事的?
它回答說,這個資訊來自我在 claude.ai 帳號上綁定的 Connector。這個解釋也符合官方架構:remote Connector 從 Anthropic 的雲端連向資料來源,不是在我的容器裡執行。所以整個過程裡,我的容器從頭到尾只跟 api.anthropic.com 講過話。
下面三張是同一場操作的連續畫面:容器裡直接連 Google Docs,以及由 Claude 後端代抓網頁都沒有拿到文件內容;Google Drive Connector 則透過帳號已授權的通道讀回私人文件。這不是防火牆失效,而是三條路原本就不在同一個執行邊界。
這條路還有一個容易漏掉的認證前提:不是只要 Claude Code 能呼叫模型,就一定會把帳號上的 Connector 帶進來。使用 Claude.ai subscription 的互動式 /login 時,帳號 Connector 才會自動出現在 Claude Code;若改用 claude setup-token 產生的 CLAUDE_CODE_OAUTH_TOKEN,目前實作不會同步這些帳號 Connector(相關 issue)。這不代表 MCP 整體不能用;另外在 Claude Code 設定的 MCP server,仍可用自己的認證獨立連線。
我後來才把這件事想清楚:
我能封鎖的是我的機器連出去。我封鎖不了我允許連的那個對象,替我連出去。
而這個缺口沒有辦法靠這道本機防火牆補起來,不是多寫一條 iptables 規則就能解決。api.anthropic.com 一定要開,不開就沒有 agent。只要那條路開著,伺服器端能做的事情就都在牆外面。控制點不在 iptables,而在帳號的 Connector 設定與 Claude Code 採用的認證路徑。
同一類的東西還有 WebSearch。官方文件講得很直接,它是「跑在 Anthropic 的搜尋後端」,那當然不經過我的網路。
後來某天的一場審查,這道牆的形狀全被拍進一張畫面裡:兩條直連出不去(其中一條當場收到 refused:錯誤是立刻回來的,不是等逾時等出來的),WebSearch 卻好好的,因為它從頭到尾沒走過我的網路。至於第一次搜尋撞到的 400:那是 stackoverflow 在擋 Anthropic 的爬蟲。別人家的牆,同一個姿態,而且它把「為什麼擋你」講了出來。
Connector 是「牆外面的事」。還有一種相反的:在牆裡面,走的是我自己批准的路,資料照樣出得去。
前面講 Docker 內部 DNS 的時候,我只講了它會被 flush 清掉。它還有另一面。
我把一顆容器接上自建網路,然後把防火牆收到最緊:OUTPUT 預設 DROP,除了 loopback 什麼都不放行,連 --dport 53 那條都不留。HTTP 直接逾時,牆是硬的。
然後我查一個當場亂湊出來的子網域:
--- 只放行 lo,其餘全 DROP
wget https://example.com → download timed out ✓ 牆有效
查詢:f25c18a7e510a6a25a466d06b0e8676abc0f5dc91d49359d.example.com
;; ->>HEADER<<- opcode: QUERY, status: NXDOMAIN
NXDOMAIN 是一個答案。至少可以確定,那 48 個字元已經離開容器,交給 Docker 的 DNS 處理。
但這次實驗沒有控制網域另一端的 DNS server,也沒有查詢紀錄,所以不能只憑這個答案斷言那串字一定抵達了 example.com:中途的 DNS resolver 也可能直接回答「不存在」。
牆是硬的,可是那串字離開容器了。
原因跟 Connector 是同一句話。容器的 /etc/resolv.conf 寫的是 127.0.0.11。查詢仍然會經過 OUTPUT,但正常規則會先放行 loopback 流量;而且 NAT 已把目的連接埠 53 改寫成 resolver 實際使用的高位連接埠,所以後面的 --dport 53 規則比對不到。接著,Docker daemon 會在容器的網路命名空間外替我繼續處理查詢。
上面那句話我是為 Connector 寫的:我封鎖不了我允許連的那個對象,替我連出去。把「那個對象」換成 Docker daemon,一個字都不用改。
這裡順手做了一組對照,結果比我預期的乾淨:
第一條規則 = REJECT udp --dport 53 → 解析成功,規則計數器 0 packets
第一條規則 = REJECT -d 127.0.0.11 → 解析 refused,計數器 1 packet 80 bytes
也就是說,腳本裡那條 iptables -A OUTPUT -p udp --dport 53 -j ACCEPT 在自建網路上根本沒有在承擔 DNS,因為 nat 表的 DNAT 跑在 filter 之前,封包到 OUTPUT 鏈的時候目的埠早就被改掉了,比對 53 的規則看不到它。那條規則是留給「接 Docker 預設 bridge、resolv.conf 指向外部 resolver」的那種情況。
這件事的另一面是:想靠「封掉 53」來收這條路的人,會封到一條一個封包都不會經過的規則,然後以為封好了。
把資料編進 DNS 查詢名稱送出去,通常稱為 DNS exfiltration;若進一步把 DNS 當成持續、甚至雙向的傳輸通道,則常稱為 DNS tunneling。近幾年有廠商報告背書的幾筆:
還有一件事值得單獨講:對方可以一個封包都不回。 資料在查詢送出去的那一刻就已經到他手上了,回應只有在需要雙向通道的時候才用得到。所以「那個網域根本查不到」不代表沒有東西出去。
幾條路我都想過一遍,寫在 docs/dns-egress-notes.md:
| 做法 | 卡在哪 |
|---|---|
| 直接封 53 | 白名單是網域,要解析才成立,封了連 api.anthropic.com 都解不出來。而且照上面那個計數器,在自建網路上「封 53」封不到東西,要擋得改成擋 127.0.0.11 |
| 開機時把白名單解析完,再封 53 | CNAME 得遞迴到底;CDN 的 IP 會隨 TTL 漂移,長 session 中途斷線,而且症狀像逾時不像被擋 |
| 自己架 resolver,只回答核准的網域 | 多一個元件要顧,而且核准網域底下的子網域一樣能編資料,比對得精確而不能只看後綴 |
| 靠偵測(查詢長度、熵、頻率) | 那是統計判斷,不是邊界。低頻慢速的外送跟正常查詢分不開 |
第二條跟前面 SSH 那段是同一個矛盾:動態跟隨 DNS,等於把白名單的控制權交給 DNS 回應。 我在 SSH 那裡選了「開機解析一次、IP 換掉就重開容器」,那個代價在 IP 很穩定的內部 GitLab 上付得起;換到走 CDN 的 api.anthropic.com,就付不起了。
所以這一條我目前是知道、寫下來、沒有封。它在 repo 裡是一條講明白的已知限制,不是一個我沒想到的洞。
這件事我不打算只用嘴巴講。後面有一天,我會把容器裡跑出去的流量攤開來給大家看,順便看看有哪些是攤不開的。
今天這道牆的形狀,其實跟前面兩天是同一個:預設不給,需要什麼再單獨開。
Day 18 的代理只放行六條規則,其餘一律 403。今天的防火牆預設 DROP,再分別放行模型 API 啟動時解析出的 IPv4、必要的直連 Docker 網段,以及有 ssh-agent 時的指定 GitLab SSH。同一個姿態,換了一層。
實作上我學到四件事:
Day 1 那張表裡的「令之以文,齊之以武」,到這裡兩半都有了:文是 Day 3 到 Day 13 那份寫給它讀的 skill,武是 Day 17 之後這幾道由外部機制執行、不再只靠 agent 自律的邊界。
然後是那兩個補不起來的缺口。我到今天還是覺得 Connector 那件事的價值不在於它有多危險,而在於它讓我看清楚這道牆的形狀:牆畫在我的機器周圍,而不是畫在我的資料周圍。這兩者在多數時候重疊,但不是同一件事。DNS 那條是同一句話的另一半:封包確實是從我的機器出去的,也確實是我自己放行的,但它帶出去的是我的資料。
明天換一個問題。牆立起來之後,我開始想知道容器裡面到底發生了什麼:一場審查花了多少時間、多少錢,而那些錢是花在哪個環節上。